本文为 Unity Entities 101官方文档的中文翻译版
实体与组件
实体是 GameObject 的轻量级非托管替代方案。实体在许多方面与 GameObject 相似,可以扮演类似的角色,但存在关键差异:
- 与 GameObject 不同,实体不是托管对象,而仅仅是一个唯一的标识编号。
- 实体的组件通常是结构体值。
- 实体的组件没有与
MonoBehaviour的“事件函数”(例如OnUpdate、OnStart)等效的功能。 - 虽然实体组件类型可以包含方法,但通常不鼓励这样做。
- 单个实体对于任何给定类型只能拥有一个组件, 例如单个实体不能同时拥有两个类型均为Foo的组件。
- 实体没有内置的父子关系概念。相反,标准的
Parent组件包含对另一个实体的引用,从而允许构建实体变换层级结构。
基本组件类型通过创建实现 IComponentData 的结构体来定义。
IComponentData 结构体应为非托管类型,因此不能包含任何托管字段类型。具体允许的字段类型包括:
- Blittable 类型
- bool
- char
BlobAssetReference<T>,指向 Blob 数据结构的引用Collections.FixedString,固定大小的字符缓冲区Collections.FixedList- Fixed array(仅允许在不安全上下文中使用)
- 符合这些相同限制的其他结构体类型
实体世界与实体管理器
World 是实体的集合。实体的 ID 编号仅在其所属世界内具有唯一性, 即不同世界中具有相同 ID 的实体彼此完全无关。
一个世界还拥有一套系统,这些系统是在主线程上运行的代码单元,通常每帧执行一次。世界中的实体通常只能由该世界的系统及其调度的作业访问(但这并非强制限制)。
世界中的实体通过世界的 EntityManager 进行创建、销毁和修改,其方法包括:
CreateEntity():创建一个新实体。Instantiate():创建一个新实体,并复制现有实体的所有组件。DestroyEntity():销毁一个现有实体。AddComponent<T>():向现有实体添加一个类型为 T 的组件。RemoveComponent<T>():从现有实体中移除一个类型为 T 的组件。HasComponent<T>():如果实体当前拥有类型为 T 的组件,则返回 true。GetComponent<T>():获取实体类型为 T 的组件值。SetComponent<T>():覆盖实体类型为 T 的组件值。
原型
原型代表世界中组件类型的特定唯一组合:世界中所有具有特定组件类型集合的实体都存储在同一个原型中。例如:
- 所有包含组件类型 A、B 和 C 的实体都存储在同一个原型中,
- 仅包含组件类型 A 和 B(不含 C)的实体则存储在第二个原型中,
- 而所有包含组件类型 B 和 D 的实体则存储在第三个原型中。
实际上,添加或移除实体的组件会改变该实体所属的原型,这要求 EntityManager 必须将实体移动到新的原型中。
当你向实体添加或移除组件时,EntityManager 会将实体移动到对应的原型中。例如,若某实体原本拥有 X、Y、Z 三种组件类型,当你移除其 Y 组件后,EntityManager 会将该实体移至仅包含 X 和 Z 组件的原型,同时保留 X 和 Z 的数值。如果当前世界中不存在这样的原型,EntityManager 会自动创建它。
注意 :频繁在原型间移动大量实体所产生的开销可能会累积到不容忽视的程度。
原型是由 EntityManager 在你创建和修改实体时自动生成的,因此无需显式创建原型。
即使某个原型中的所有实体都被移除,该原型也会持续存在直至其所属的世界被销毁。
数据块
原型中的实体存储在属于该原型的 16KiB 内存块中,这些内存块被称为数据块 。每个数据块最多存储 128 个实体。(如果某个原型中每个实体所需空间超过 16KiB/128,则每个数据块的最大实体数量会相应减少)。
每个类型的实体 ID 和组件都存储在数据块内各自独立的数组中。例如,在包含组件类型 A 和 B 的实体原型中,每个数据块将存储三个数组:
- 一个数组用于存储实体 ID
- 第二个数组用于存储 A 组件
- 第三个数组用于存储 B 组件
区块中第一个实体的 ID 和组件存储在这些数组的索引 0 位置,第二个实体存储在索引 1 位置,第三个实体存储在索引 2 位置,依此类推。
区块的数组始终保持紧密排列:
- 当新实体被添加到块中时,它会被存储在数组的第一个空闲索引位置。
- 当实体从块中移除时(无论是由于实体被销毁还是因为它被移动到另一个原型),块中的最后一个实体会被移动以填补空缺。
块的创建和销毁由 EntityManager 处理:
- 仅当实体被添加到其所有现有块都已满的原型时,才会创建新块。
- 仅在区块中的最后一个实体被移除时才会销毁该区块。
任何在区块内添加、移除或移动实体的 EntityManager 操作都被称为结构变更。这类变更只能在主线程中进行,无法在作业中执行(不过正如我们稍后将讨论的,可以使用EntityCommandBuffer 作为变通方案)。
查询
EntityQuery 能够高效地查找所有具有指定组件类型集合的实体。例如,如果某个查询要查找所有具有组件类型 A 和 B 的实体,那么该查询将收集所有包含 A 和 B 的原型对应的区块,无论这些原型可能包含哪些其他组件类型。这样的查询不仅会匹配具有组件类型 A 和 B 的实体,同时也会匹配例如具有组件类型 A、B 和 C 的实体。
注意 :与查询匹配的原型将被缓存,直到下次向世界添加新原型为止。由于世界中现有原型集合往往在程序生命周期早期趋于稳定,这种缓存机制通常有助于大幅降低查询开销。
查询还可以指定要从匹配原型中排除的组件类型。例如,若某查询要求"查找所有拥有组件类型 A 和 B 但不包含组件类型 C 的实体",则该查询将匹配具有 A 和 B 组件类型的实体,但不会匹配同时具有 A、B 和 C 组件类型的实体。
实体 ID
实体 ID 通过 Entity 结构体表示,该结构体包含两个整型参数: index 和 version
为了通过 ID 查找实体,世界的 EntityManager 维护着一个实体元数据数组。实体的索引表示其在该元数据数组中的槽位,该槽位存储着指向该实体所在块的指针,以及实体在块内的索引。当某个索引不存在实体时,该索引处的块指针为空。例如,当前索引 1、2 和 5 处不存在实体,因此这些槽位中的块指针均为空:

实体版本号允许在实体被销毁后重复使用实体索引:当实体被销毁时,其索引处存储的版本号会递增,因此如果某个 ID 的版本号与索引处存储的版本号不匹配,则该 ID 必然指向一个已被销毁或可能从未存在过的实体。
标记组件
没有字段的 IComponentData 结构体称为标签组件。虽然标签组件不存储任何数据,但它们仍可以像其他组件类型一样在实体上添加和移除,这对查询非常有用。例如,如果我们所有代表怪物的实体都有一个 Monster 标签组件,那么查询 Monster 组件类型将匹配所有怪物实体。
动态缓冲区组件
DynamicBuffer 是一种可调整大小的数组组件类型。要定义 DynamicBuffer 组件类型,需要创建一个实现 IBufferElementData 接口的结构体。
每个实体的缓冲区存储着长度、容量和一个指针:
Length表示缓冲区中的元素数量。它从0开始,当您向缓冲区追加值时递增。Capacity表示缓冲区的存储空间大小。它最初与内部缓冲区容量相匹配(默认为 128/sizeof(T) ,但可通过IBufferElementData结构体上的InternalBufferCapacity属性指定)。设置容量会调整缓冲区大小。- 指针指示缓冲区内容的位置。初始状态下它为 null,表示内容直接存储在区块中。如果设置的容量超过内部缓冲区容量,将在区块外分配一个更大的新数组,内容被复制到这个外部数组,指针将指向这个新数组。如果缓冲区长度超过外部数组的容量,缓冲区内容将被复制到区块外另一个更大的新数组中,旧数组会被释放。缓冲区也可以被缩小。
当 EntityManager 销毁区块本身时,内部缓冲区容量和外部容量(如果存在)将被释放。
注意 :当动态缓冲区存储在区块外时,内部容量实际上被浪费了,且访问缓冲区内容需要额外追踪指针。如果确保不超过内部容量限制,就能避免这些开销。当然,在许多情况下,要维持在这个限制内可能需要过大的内部容量。另一种选择是将内部容量设为 0,这意味着任何非空缓冲区都将始终存储在区块外。这会导致访问缓冲区时始终需要追踪指针,但避免了区块中未使用空间的浪费。
EntityManager 为动态缓冲区提供了以下关键方法:
AddComponent<T>():向实体添加类型为 T 的组件,其中 T 可以是动态缓冲区组件类型。AddBuffer<T>():向实体添加类型为 T 的动态缓冲区组件;以DynamicBuffer<T>形式返回新缓冲区。RemoveComponent<T>():从实体中移除类型为 T 的组件,其中 T 可以是动态缓冲区组件类型。HasBuffer<T>():如果实体当前拥有类型为 T 的动态缓冲区组件,则返回 true。GetBuffer<T>():将实体的类型为 T 的动态缓冲区组件以DynamicBuffer<T>形式返回。
DynamicBuffer<T> 表示单个实体的类型为 T 的动态缓冲区组件。其关键属性和方法包括:
Length:获取或设置缓冲区的长度。Capacity:获取或设置缓冲区的容量。Item[Int32]:获取或设置指定索引处的元素。Add():将元素添加到缓冲区末尾,必要时会调整其大小。Insert():在指定索引处插入元素,必要时会调整大小。RemoveAt():移除指定索引处的元素。
注意 :执行任何结构性变更操作都会使DynamicBuffer“失效”,这意味着如果随后使用该DynamicBuffer,将抛出异常。若要在结构性变更后再次使用缓冲区,必须重新获取它。
系统
系统是属于实体世界的一个代码单元,在主线程上运行(通常每帧一次)。通常,系统只会访问其所属世界的实体,但这并非强制限制。
系统被定义为实现 ISystem 接口的结构体,该接口包含三个关键方法:
OnUpdate():通常每帧调用一次(具体取决于系统所属的系统组)OnCreate():在首次调用OnUpdate前以及系统恢复运行时调用OnDestroy():在系统被销毁时调用
注意 :这些方法都有默认的空实现,因此当您不需要它们时,可以在系统中省略这些方法, 例如如果您将OnCreate方法体留空,可以直接省略整个方法。
如果系统的 Enabled 属性设置为 false,其更新将被跳过。
系统还可以额外实现 ISystemStartStop 接口,该接口包含以下方法:
OnStartRunning():在首次调用OnUpdate之前以及系统被重新启用后(即系统的Enabled属性从false更改为true时)调用。OnStopRunning():在OnDestroy之前以及系统被禁用后调用(即系统的Enabled属性从true变为false时)。
系统组与系统更新顺序
世界中的系统被组织成系统组 。每个系统组都有一个有序的子项列表(系统和其他系统组),默认情况下,每个系统组按其排序顺序调用子项的 OnUpdate。实际上,系统组形成了一个决定系统更新顺序的层次结构。
- 系统组被定义为一个继承自
ComponentSystemGroup的类。 - 当系统组更新时,该组通常会按排序顺序更新其子项,但可以通过重写组的更新方法来覆盖此默认行为。例如,
FixedStepSimulationGroup具有自定义更新行为,每帧会零次或多次更新其子项,以近似固定的更新间隔。 - 每当向组中添加或移除子项时,组的子项都会重新排序。
- 通过
UpdateInGroup特性将子项添加到系统组。若未使用此特性,系统和系统组默认会添加到SimulationSystemGroup中。 UpdateBefore和UpdateAfter特性可用于确定组内子项间的相对排序顺序。例如,若 FooSystem 具有特性[UpdateBefore(typeof(BarSystem))],则 FooSystem 将在排序顺序中被置于 BarSystem 之前的某个位置。但如果 FooSystem 和 BarSystem 不属于同一系统组,此特性除了触发警告外不会产生任何效果。- 如果某个组的子项排序属性存在矛盾( 例如子项 A 被标记为在子项 B 之前更新,但子项 B 同时被标记为在子项 A 之前更新),则在排序该组的子项时会抛出异常。
创建世界与系统
当进入运行模式时,默认的自动引导流程会创建一个包含三个系统组的默认世界:
InitializationSystemGroup:在 Unity 玩家循环的初始化阶段末尾更新。SimulationSystemGroup:在 Unity 玩家循环的Update阶段末尾更新。通常是放置游戏逻辑的地方。PresentationSystemGroup:在 Unity 玩家循环的PreLateUpdate阶段末尾更新。通常是放置渲染代码的地方。
自动引导过程会创建每个系统和系统组的实例(除了带有 DisableAutoCreation 属性的系统)。这些实例会被添加到 SimulationSystemGroup 中,除非被 UpdateInGroup 属性覆盖。例如,如果一个系统具有 [UpdateInGroup(typeof(InitializationSystemGroup))] 属性,那么该系统将被添加到 InitializationSystemGroup 中,而不是 SimulationSystemGroup 中。
可以通过脚本定义禁用自动引导过程:
#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_RUNTIME_WORLD:禁用默认世界的自动引导。#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_EDITOR_WORLD:禁用编辑器世界的自动引导。#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP:同时禁用默认世界和编辑器世界的自动引导。
当禁用自动引导时,您的代码需要负责:
- 创造世界。
- 调用
World.GetOrCreateSystem<T>()将系统和系统组实例添加到世界中。 - 在 Unity 玩家循环中注册顶级系统组(如
SimulationSystemGroup)以进行更新。
或者,您可以通过创建实现 ICustomBootstrap 接口的类来自定义引导逻辑。
世界与系统中的时间
每个世界都有一个 Time 属性,它返回一个 TimeData 结构体,其中包含帧增量时间和已用时间。该时间值由世界的 UpdateWorldTimeSystem 更新。可以通过以下 World 方法来操作时间值:
SetTime:设置时间值。PushTime:临时更改时间值。PopTime:恢复最近一次推送之前的时间值。
某些系统组(例如 FixedStepSimulationSystemGroup)会在更新其子项前推送时间值,并在完成更新后弹出该值。实际上,这些系统组向其子项呈现的是"虚假"时间值。
SystemState
系统的 OnUpdate()、OnCreate() 和 OnDestroy() 方法会接收 SystemState 参数。SystemState 代表系统实例的状态,包含以下重要方法和属性:
- 世界:该系统所属的世界。
- 实体管理器:该系统所属世界的实体管理器。
- 依赖关系:用于在系统间传递作业依赖关系的
JobHandle。 - 获取实体查询():返回一个实体查询。
GetComponentTypeHandle<T>(): 返回一个ComponentTypeHandle<T>。GetComponentLookup<T>(): 返回一个ComponentLookup<T>。重要提示 :虽然可以直接从 EntityManager 获取实体查询、组件类型句柄和组件查找器,但通常系统应仅从 SystemState 获取这些内容。通过 SystemState 获取时,系统访问的组件类型会被跟踪,这对于系统的 Dependency 属性正确传递系统间的作业依赖关系至关重要(稍后讨论)。
SystemAPI
SystemAPI 类包含许多静态便捷方法,其功能范围与 World、EntityManager 和 SystemState 类高度重合。
SystemAPI 方法依赖于源码生成器,因此仅适用于系统内部和 IJobEntity(不适用于 IJobChunk)。使用 SystemAPI 的优势在于这些方法在两种上下文中能产生相同结果,因此采用 SystemAPI 的代码通常能更轻松地在两种场景间复制粘贴。
注意 :若不确定在何处查找 Entities 核心功能,通用准则是优先查阅 SystemAPI。若 SystemAPI 未提供所需功能,可查看 SystemState,若仍未找到,最后查阅 EntityManager 和 World。
SystemAPI 还提供特殊的便捷 Query() 方法,通过源码生成器可快速创建遍历匹配查询的实体与组件的 foreach 循环(如下方示例所示)。
在作业中访问实体
您可以通过 C#作业系统将实体数据的处理工作卸载到工作线程。Entities 包提供了两个用于定义访问实体的作业接口:
IJobChunk接口,其Execute()方法会对查询匹配的每个独立块调用一次。IJobEntity接口,其Execute()方法会对查询匹配的每个实体调用一次。
虽然 IJobEntity 通常编写和使用更为便捷,但 IJobChunk 提供了更精确的控制。在大多数情况下,两者在执行等效工作时的性能表现相同。
注意 :IJobEntity 实际上并非"真正的"任务类型:源代码生成会通过 IJobChunk 的实现来扩展 IJobEntity 结构体,因此实际上 IJobEntity 最终是作为 IJobChunk 被调度的。
要将 IJobChunk 或 IJobEntity 的工作分配到多个线程中执行,请通过调用 ScheduleParallel() 而非 Schedule() 来调度任务。当使用 ScheduleParallel() 时,匹配查询的块将被分配到不同的批次中,这些批次将被分配给工作线程处理。
结构性变更无法在任务内部执行,因此您应当仅在主线程中进行结构性变更。不过,任务可以通过 EntityCommandBuffer(后续将讨论)记录结构性变更命令,这些命令随后可在主线程上回放执行。
同步点
在主线程上的一些操作会触发"同步点",这些同步点会完成部分或全部当前已调度的作业。例如,调用 EntityManager.AddComponent<T>() 会首先完成所有当前已调度的访问任何 T 组件的作业。同样地,EntityQuery 方法 ToComponentDataArray<T>()、ToEntityArray() 和 ToArchetypeChunkArray() 也必须首先完成任何当前已调度的、访问与查询相同组件的作业。
在许多情况下,这些同步点还会"使"某些类型的现有实例"失效",特别是 DynamicBuffer 和 ComponentLookup<T>。当一个实例失效后,调用其方法将抛出安全检查异常。如果您仍需使用某个已失效的实例,必须获取一个新实例来替换它。
组件安全句柄
与原生集合类似,每个组件类型在每个世界中都有一个关联的作业安全句柄。这意味着,对于任何两个在同一世界中访问相同组件类型的作业,安全检查将不允许这些作业并发调度。例如,当我们尝试调度一个访问组件类型 Foo 的作业时,如果已调度的作业也访问组件类型 Foo,安全检查将抛出异常。为避免此异常:
必须在新作业调度前完成已调度的作业
...或者新作业必须依赖于已调度的作业。
注意 :若两个作业对同一组件类型均具有只读访问权限,则并发调度是安全的。对于作业中从不写入的任何组件类型,请务必通过使用 ReadOnly 属性标记组件类型句柄来告知安全检查系统。
DynamicBuffer<T> 实例本身持有一个安全句柄:
- 当任何访问相同缓冲区组件类型的已调度作业仍未完成时,无法访问
DynamicBuffer<T>的内容。 - 但如果未完成的作业都仅对缓冲区组件类型具有只读访问权限,则允许主线程读取该缓冲区。
SystemState.Dependency
当我们在系统中调度一个作业时,我们希望它依赖于任何可能与新作业冲突的当前已调度作业,即使这些作业是在其他系统中调度的。为了帮助安排这些依赖关系,SystemState 有一个名为 Dependency 的 JobHandle 属性。
在系统即将更新之前:
- 系统的
Dependency属性会被完成 - ...然后
Dependency会被赋予所有其他系统中访问了与此系统相同组件类型的Dependency作业句柄的组合。例如,在一个访问了 Foo 和 Bar 组件类型的系统中,世界中所有同样访问了 Foo 或 Bar 的其他系统的Dependency将被组合成一个作业句柄,并分配给此系统的Dependency。
在每一个系统中,你都需要遵循两条规则:
- 系统更新中调度的所有任务都应(直接或间接)依赖于更新前分配给
Dependency属性的任务句柄。 - 在系统更新返回之前,应为
Dependency属性分配一个包含该次更新中所有已调度任务的句柄。
只要遵循这两条规则,系统更新中调度的每个任务都将依赖于其他系统中可能访问相同组件类型的所有已调度任务。
重要提示 :系统不会追踪它们使用的原生容器,因此 Dependency 属性仅考虑组件类型,而不包括原生容器。因此,如果两个系统都调度使用相同原生容器的作业,它们的 Dependency 作业句柄不一定会包含在分配给另一个系统 Dependency 属性的作业句柄中,因此不同系统的作业将不会按预期相互依赖。在这些情况下,您可以通过在系统之间手动共享作业句柄来安排依赖关系,但通常更好的解决方案是将原生容器存储在组件中:如果您遵循这两条规则,并且两个系统都通过相同的组件类型访问该容器,那么在这两个系统中调度的作业应该会相互依赖。
ComponentLookup <T>
虽然可以通过 EntityManager 随机访问单个实体的组件,但我们通常无法在作业内部使用 EntityManager。取而代之的是,我们可以使用名为 ComponentLookup<T> 的类型,它通过实体 ID 获取和设置组件值。我们还可以使用 BufferLookup<T> 通过实体 ID 获取动态缓冲区。
重要 :请记住,通过 ID 查找实体往往会产生缓存未命中的性能开销,因此通常应尽可能避免查找操作。当然,许多问题确实需要通过随机查找来解决,所以随机查找不可能被完全避免!只需注意不要随意滥用即可。
ComponentLookup<T> 和 BufferLookup<T> 的 HasComponent() 方法会在指定实体拥有 T 类型组件时返回 true。 TryGetComponent<T>() 和 TryGetBuffer<T>() 方法具有相同功能,但还会在组件存在时输出组件值或缓冲区。
注意 :要测试实体是否存在,我们可以调用 EntityStorageInfoLookup 的 Exists() 方法。对 EntityStorageInfoLookup 进行索引会返回 EntityStorageInfo 结构体,其中包含对实体所在块(chunk)及其在块内索引的引用。
若作业只需读取通过 ComponentLookup<T> 访问的组件,则应将 ComponentLookup<T> 字段标记为 ReadOnly 属性以通过作业安全检查。此规则同样适用于 BufferLookup<T>。
在并行调度的作业中,从 ComponentLookup<T > 获取组件值需要将字段标记为 ReadOnly 属性。安全检查不允许在并行调度的作业中通过 ComponentLookup<T> 设置组件值,因为无法保证安全性。但您可以通过标记 NativeDisableParallelForRestriction 属性来完全禁用 ComponentLookup<T> 的安全检查。BufferLookup<T> 也是如此。请确保您的代码以线程安全的方式设置组件值!
实体命令缓冲区
对实体的更改可以通过将命令记录到 EntityCommandBuffer 中来延迟执行。记录的命令会在主线程上调用 Playback() 方法时执行。
使用 EntityCommandBuffer 延迟更改在作业中特别有用,因为作业无法直接进行结构性更改(即创建实体、销毁实体、添加组件或移除组件)。作业可以通过将命令记录在 EntityCommandBuffer 中,待作业完成后在主线程上回放这些命令。EntityCommandBuffer 还能帮助我们将结构性更改延迟到帧内的几个整合点执行,而非分散在整个帧中,从而避免不必要的同步点。
EntityCommandBuffer 拥有许多(但非全部)与 EntityManager 相同的方法,包括:
CreateEntity():记录创建新实体的命令,返回临时实体 ID。DestroyEntity():记录销毁实体的命令。AddComponent<T>(): 记录向实体添加类型为 T 的组件的命令。RemoveComponent<T>(): 记录从实体移除类型为 T 的组件的命令。SetComponent<T>(): 记录设置类型为 T 的组件值的命令。AppendToBuffer(): 记录向现有缓冲区组件末尾追加单个值的命令。AddBuffer():返回一个存储在记录命令中的DynamicBuffer,当实体在回放过程中创建时,该缓冲区的内容将被复制到实体的实际缓冲区中。实际上,对返回的缓冲区进行写入操作可以设置缓冲区组件的初始内容。SetBuffer():与AddBuffer()类似,但它假设实体已经具有该组件类型的缓冲区。在回放过程中,实体已有的缓冲区内容将被返回的缓冲区内容覆盖。注意 :某些 EntityManager 方法没有对应的 EntityCommandBuffer 等效方法,因为等效方法不可行或无意义。例如,EntityCommandBuffer 没有获取组件值的方法,因为读取数据无法有效地延迟执行。
注意 :EntityCommandBuffer 实例在回放后无法用于继续记录命令。如需记录更多命令,请创建新的独立 EntityCommandBuffer 实例。
每个 EntityCommandBuffer 都有一个作业安全句柄,因此当出现以下情况时,安全检查会抛出异常:
...在主线程上调用
EntityCommandBuffer的方法,而该EntityCommandBuffer仍被任何当前已调度的作业使用。...或调度一个访问已被其他当前已调度作业使用的
EntityCommandBuffer的作业(除非新作业依赖于那些其他作业)。重要提示 :您可能会想在多个作业间共享单个 EntityCommandBuffer 实例,但强烈不建议这样做。在某些情况下它可能正常工作,但在许多情况下会出现问题。例如,在多个并行作业中使用相同的 EntityCommandBuffer.ParallelWriter 可能导致命令的回放顺序出现意外情况。相反,最佳做法几乎总是为每个作业创建并使用一个 EntityCommandBuffer。不必担心性能差异:将一组命令拆分到多个 EntityCommandBuffer 中进行记录和回放,实际上并不会比将所有命令记录到单个 EntityCommandBuffer 中更加耗费资源。
临时实体 ID
当你调用 EntityCommandBuffer 的 CreateEntity() 或 Instantiate() 方法时,在回放执行命令之前不会创建新实体,因此这些方法返回的实体 ID 是临时 ID,具有负索引号。同一 EntityCommandBuffer 的后续 AddComponent、SetComponent 和 SetBuffer 命令可以使用这些临时 ID。在回放过程中,记录命令中的任何临时 ID 都将被重新映射到实际存在的实体。
重要提示 :由于临时实体 ID 在其创建的 EntityCommandBuffer 实例之外没有意义,因此临时实体 ID 应仅在同一 EntityCommandBuffer 实例的后续方法调用中使用。例如,在向不同的 EntityCommandBuffer 实例记录命令时,不要使用从另一个 EntityCommandBuffer 获取的临时 ID。
EntityCommandBuffer.ParallelWriter
为了安全地记录并行作业中的命令,我们需要使用 EntityCommandBuffer.ParallelWriter ——这是一个基于底层 EntityCommandBuffer 的封装器。ParallelWriter 具备与 EntityCommandBuffer 本身大部分相同的方法,但为了确保确定性,ParallelWriter 的所有方法都需要额外传入一个“排序键”参数。
当 EntityCommandBuffer.ParallelWriter 在并行作业中记录命令时,来自不同线程的命令在缓冲区中的顺序取决于线程调度,导致顺序具有不确定性。这种非确定性会带来以下问题:
- 确定性代码通常更易于调试。
- 部分网络代码解决方案依赖确定性来确保在不同机器上产生一致的结果。
虽然命令的录制顺序无法确定,但通过一个简单技巧可以让回放顺序变得确定:
- 每个命令会记录一个"排序键"整数,该整数作为首个参数传递给每个命令方法。
- 回放方法在执行命令前会按排序键对命令进行排序。
只要排序键值与每个已录制命令之间存在确定性映射关系,这种排序机制就能确保回放顺序的确定性。
在 IJobEntity 中,我们通常希望使用的排序键是 ChunkIndexInQuery,这是每个块独有的值。由于排序是稳定的,并且单个块的所有实体都在单个线程中一起处理,因此该索引值适合作为记录命令的排序键。在 IJobChunk 中,我们可以使用 Execute 方法中等效的 unfilteredChunkIndex 参数。
多重回放
如果使用 PlaybackPolicy.MultiPlayback 选项创建 EntityCommandBuffer,其 Playback 方法可以被多次调用。否则,多次调用 Playback 将抛出异常。多重回放主要适用于需要重复生成一组实体的情况。
EntityCommandBufferSystem
EntityCommandBufferSystem 是一种系统,它提供了一种便捷的方式来延迟 EntityCommandBuffer 的执行。从 EntityCommandBufferSystem 创建的 EntityCommandBuffer 实例将在下一次 EntityCommandBufferSystem 更新时被执行并释放。
重要提示 :请勿手动执行和释放由 EntityCommandBufferSystem 创建的 EntityCommandBuffer 实例,EntityCommandBufferSystem 会为您执行并释放该实例。
您很少需要自行创建任何 EntityCommandBufferSystem,因为自动引导过程会将以下五个系统放入默认世界中:
BeginInitializationEntityCommandBufferSystemEndInitializationEntityCommandBufferSystemBeginSimulationEntityCommandBufferSystemEndSimulationEntityCommandBufferSystemBeginPresentationEntityCommandBufferSystem
例如,EndSimulationEntityCommandBufferSystem 在 SimulationSystemGroup 结束时更新。
注意 :帧结束时没有“EndPresentationEntityCommandBufferSystem”,但你可以改用 BeginInitializationEntityCommandBufferSystem:因为一帧的结束与下一帧的开始在逻辑上是同一时间点。
变换组件与系统
LocalTransform 是表示实体变换的主要标准组件。变换层级结构可以通过三个附加组件来构建:
Parent组件存储该实体父级的 ID。Child动态缓冲区组件存储实体的子级 ID。PreviousParent组件存储实体父级 ID 的副本。
要修改变换层级结构:
- 添加
Parent组件以将实体设为父级。 - 移除实体的
Parent组件以解除其父子关系。 - 设置实体的
Parent组件以更改其父级对象。
ParentSystem 将确保:
每个拥有父级的实体都会包含一个指向其父级的
PreviousParent组件。每个拥有一个或多个子实体的父实体都带有一个
Child缓冲区组件,该组件会引用其所有子实体。重要提示 :虽然可以安全读取实体的 Child 缓冲区和 PreviousParent 组件,但不应直接修改它们。只能通过设置实体的 Parent 组件来修改变换层级结构。
每帧中,LocalToWorldSystem 会计算每个实体的世界空间变换(根据该实体及其祖先的 LocalTransform 组件),并将其赋值给实体的 LocalToWorld 组件。
注意 :Entity.Graphics 系统会读取 LocalToWorld 组件,但不会读取其他任何变换组件,因此 LocalToWorld 是实体渲染所需的唯一变换组件。
烘焙与实体场景
烘焙是通过执行烘焙器和烘焙系统 ,在构建时从子场景创建实体场景的过程:
- 实体场景是一组经过序列化的实体和组件,可在运行时加载。
- 子场景是通过
SubScene MonoBehaviour嵌入到其他场景中的 Unity 场景资源。 - 烘焙器是继承
Baker<T>的类,其中 T 是MonoBehaviour。带有Baker的MonoBehaviour被称为“创作组件”。 - 烘焙系统是带有
[WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)]属性的系统。(烘焙系统通常仅在高级使用场景中需要。)
子场景的烘焙过程包含以下几个主要步骤:
- 为子场景中的每个游戏对象创建对应的实体。
- 执行子场景中每个创作组件的烘焙器。每个烘焙器可以读取创作组件,并向对应实体添加组件。
- 烘焙系统被执行。每个系统都可以读取和修改已烘焙的实体。与烘焙器不同,烘焙系统不应访问子场景的原始游戏对象。
当子场景被修改时,会重新进行烘焙:
- 只有被修改的创作组件的烘焙器会被重新执行。
- 烘焙系统总是会被完整地重新执行。
- 编辑模式或运行模式中的实时实体将更新以匹配烘焙结果。(这之所以可行,是因为烘焙会追踪烘焙实体与实时实体的对应关系。)
创建和编辑子场景
带有 SubScene MonoBehaviour 的 GameObject 有一个复选框,用于打开和关闭子场景进行编辑。当子场景处于打开状态时,其包含的 GameObject 将被加载并占用 Unity 编辑器资源,因此您可能希望关闭当前未编辑的大型子场景。

创建新子场景的便捷方式是在层级窗口中右键单击,选择新建子场景 > 空场景...。这将同时创建一个新的场景文件和一个带有 SubScene 组件的 GameObject,该组件会引用这个新场景文件:

在烘焙器中访问数据
增量烘焙要求烘焙器跟踪其读取的所有数据。烘焙器创作组件的字段会被自动跟踪,但烘焙器读取的其他数据必须通过 Baker 方法添加到其依赖项列表中:
GetComponent<T>():访问子场景中 GameObject 的组件。DependsOn():声明应为此烘焙器跟踪某个资源。GetEntity():返回子场景中烘焙或从预制件烘焙的实体的 ID。(该实体尚未完全烘焙完成,因此不应尝试通过此 ID 读取或修改该实体的组件。)
加载和卸载实体场景
为实现流式加载,场景中的实体被按索引号划分为多个区块。实体所属的区块由其 SceneSection 共享组件指定。默认情况下实体属于 0 号区块,但可通过在烘焙时设置 SceneSection 来更改此设置。
重要说明 :在烘焙过程中,子场景中的实体只能引用同一区块或 0 号区块的其他实体。(0 号区块是特例,因为它总是先于其他区块加载,并且仅在场景本身被卸载时才会卸载)。
当场景被加载时,它由一个包含场景元数据的实体表示,其各个分区也分别由实体表示。通过操作实体上的 RequestSceneLoaded 组件来加载和卸载单个分区:当该组件发生变化时,SceneSystemGroup 中的 SceneSectionStreamingSystem 会作出响应。
若要通过代码加载和卸载实体场景,请使用 SceneSystem 的静态方法:
LoadSceneAsync():启动场景加载。返回代表已加载场景的实体。LoadPrefabAsync():启动预制件加载。返回引用已加载预制件的实体。UnloadScene():销毁已加载场景的所有实体。IsSceneLoaded():如果场景已加载则返回 true。IsSectionLoaded():如果场景区块已加载则返回 true。GetSceneGUID():返回代表场景资源(通过文件路径指定)的 GUID。GetScenePath():返回场景资源的路径(由其 GUID 指定)。GetSceneEntity():返回表示场景的实体(由其 GUID 指定)。重要提示 :实体场景和章节的加载始终是异步的,无法保证请求后数据加载完成所需的时间。在大多数情况下,代码应检查从场景加载的特定数据是否存在,而不是检查场景本身的加载状态。这种方法避免了将代码与特定场景绑定:如果数据被移动到不同场景、从网络下载或通过程序生成,代码仍可在无需修改的情况下正常工作。
附加功能
托管式 IComponentData 组件
实现 IComponentData 接口的类属于托管组件类型。与作为非托管类型的 IComponentData 结构体不同,这些托管组件可以存储任何托管对象。
通常只有在确实需要时才应使用托管组件类型,因为与非托管组件相比,它们会产生一些较大开销:
- 与所有托管对象一样,托管组件无法在 Burst 编译代码中使用。
- 托管对象通常无法在作业中安全使用。
- 托管组件并非直接存储在区块中:相反,世界中所有托管组件都存储在一个大型数组中,而区块仅存储该数组的索引。
- 与所有托管对象一样,创建托管组件会产生垃圾回收开销。
为确保托管组件包含的资源在组件本身被复制时得到复制,该组件应实现 ICloneable 接口。为确保托管组件包含的资源在托管组件被销毁时得到妥善释放,该组件应实现 IDisposable 接口。
可启用组件
实现 IComponentData 或 IBufferElementData 的结构体也可实现 IEnableableComponent。实现此接口的组件类型可按实体启用和禁用。
当实体的组件被禁用时,查询会认为该实体不具有该组件类型:
- 在
SystemAPI.Query()、IJobEntity以及其他遍历查询匹配区块中实体的上下文中,因组件启用或禁用状态而不匹配查询的单个实体将被跳过。 - 若因组件启用或禁用状态导致区块中无实体匹配查询,该区块将不会包含在
EntityQuery的ToArchetypeChunkArray()方法返回的数组中。
请明确,禁用组件不会移除或修改组件:而是切换与特定实体的特定组件相关联的一个位。同时请明确,禁用的组件仅影响查询:除此之外,禁用的组件仍可正常读取和修改,例如通过 EntityManager 的 GetComponent() 方法。
所有可启用组件在添加到实体时默认处于启用状态。当实体被复制以进行序列化、复制到另一个世界或被 EntityManager 的 Instantiate 方法复制时,组件的启用状态也会被复制。
可以通过以下方式检查和设置实体组件的启用状态:
EntityManager组件查找器<T>缓冲区查找器<T>启用状态引用读写器<T>ArchetypeChunk
例如,EntityManager 包含以下方法:
IsComponentEnabled<T>():如果实体当前启用了 T 组件,则返回 true。SetComponentEnabled<T>():启用或禁用实体的可启用 T 组件。注意 :为了作业安全检查,读取或写入组件启用状态需要对该组件类型本身具有读取或写入权限。
在 IJobChunk 中,Execute 方法的参数指示区块中哪些实体符合查询条件:
如果
useEnableMask参数为 false,则区块中的所有实体都符合查询条件。否则,如果
useEnableMask参数为 true,则chunkEnabledMask参数的位标志会指示区块中哪些实体在考虑查询中所有可启用组件类型后符合查询条件。您无需手动检查这些掩码位,可以通过ChunkEntityEnumerator更便捷地遍历匹配的实体。注意 :chunkEnabledMask 是作业查询中包含的所有可启用组件启用状态的复合结果。要检查单个组件的启用状态,请使用 ArchetypeChunk 的 IsComponentEnabled() 和 SetComponentEnabled() 方法。
方面
方面是一种类似对象的包装器,用于封装实体组件的子集。方面可用于简化查询和与组件相关的代码。例如,我们可以定义一个“MonsterAspect”,将构成怪物实体的组件组合在一起。
方面被定义为实现 IAspect 接口的只读部分结构体。该结构体可以包含以下类型的字段:
Entity:被包装实体的实体 ID。RefRW<T>或RefRO<T>:指向被包装实体的 T 组件的引用。EnabledRefRW<T>和EnabledRefRO<T>:指向被包装实体的 T 组件启用状态的引用。DynamicBuffer<T>:被包装实体的动态缓冲区 T 组件。- 另一种切面类型:包含切面将涵盖所有"嵌入式"切面的字段。
在查询中包含一个方面等同于包含该方面所封装的所有独立组件。
这些 EntityManager 方法会创建某个方面的实例:
GetAspect<T>:返回封装实体的类型 T 的方面。GetAspectRO<T>:返回封装实体的类型 T 的只读方面。如果使用任何试图修改底层组件的方法或属性,只读方面会抛出异常。
可以通过 SystemAPI.GetAspectRW<T> 或 SystemAPI.GetAspectRO<T> 获取 Aspect 实例,并在 IJobEntity 或 SystemAPI.Query() 循环中访问。
重要提示 :通常应通过 SystemAPI 而非 EntityManager 获取 Aspect 实例:与 EntityManager 方法不同,SystemAPI 方法会将 Aspect 的基础组件类型注册到系统中,这对于系统正确调度具有所有必需依赖项的作业至关重要。
共享组件
对于共享组件类型,区块内的所有实体共享相同的组件值,而非每个实体拥有各自的值。因此,设置实体的共享组件值会执行结构变更:该实体会被移至具有新值的区块。例如,若某实体具有 Foo 共享组件值 X,则该实体存储在具有 Foo 值 X 的区块中;若随后将该实体的 Foo 值设置为 Y,该实体将被移至具有值 Y 的区块;若尚不存在此类区块,则会创建新区块。
共享组件的主要用途在于查询能够根据特定的共享组件值进行筛选。例如,包含共享组件 Foo 的查询可以设置过滤器,指定仅匹配具有 Foo 值 X 的实体。
世界不会将共享组件值直接存储在区块中,而是将其存储在一组数组内,区块仅存储这些数组的索引。这意味着每个唯一的共享组件值在世界中仅存储一次。
注意 :可通过为类型实现 IEquatable 接口来自定义共享组件类型的相等性比较方式,以确定唯一性。
共享组件类型通过实现 ISharedComponentData 接口的结构体来声明。如果该结构体包含任何托管类型字段,则该共享组件本身将被视为托管组件类型,具有与托管 IComponentData 相同的优势和限制。
EntityManager 包含以下用于共享组件的关键方法:
AddComponent<T>():向实体添加 T 类型组件,其中 T 可以是共享组件类型。AddSharedComponent():向实体添加非托管共享组件并设置其初始值。AddSharedComponentManaged():向实体添加托管共享组件并设置其初始值。RemoveComponent<T>():从实体中移除一个 T 类型的组件,其中 T 可以是共享组件类型。HasComponent<T>():如果实体当前拥有 T 类型的组件则返回 true,其中类型 T 可以是共享组件类型。GetSharedComponent<T>():获取实体的非托管共享 T 组件的值。SetSharedComponent<T>():覆盖实体的非托管共享 T 组件的值。GetSharedComponentManaged<T>():获取实体的托管共享 T 组件的值。SetSharedComponentManaged<T>():覆盖实体的托管共享 T 组件的值。重要提示 :由于 EntityManager 依赖相等性来识别唯一且匹配的共享组件值,您应避免修改共享组件引用的任何可变对象。例如,如果您想要修改存储在特定实体共享组件中的数组,不应直接修改该数组,而应更新该实体的组件,使其拥有一个新的、修改后的数组副本。
重要提示 :使用过多唯一的共享组件值可能导致区块碎片化!由于同一区块中的所有实体必须共享相同的共享组件值,如果您为大量实体赋予唯一的共享组件值,这些实体最终将分散存储在多个区块中。例如,若某个原型有 500 个带共享组件的实体,且每个实体都具有唯一的共享组件值,则每个实体都将单独存储在不同的区块中。这会浪费每个区块中的大部分空间,同时也意味着遍历该原型的所有实体需要访问 500 个区块。这种碎片化很大程度上抵消了 ECS 架构带来的性能优势。为避免此问题,请尽量使用最少数量的唯一共享组件值。举例来说,如果这 500 个实体仅共享十个唯一共享组件值,它们可能只需存储在十个区块中。
清理组件
清理组件在以下两个方面具有特殊性:
- 当带有清理组件的实体被销毁时,非清理组件会被移除,但该实体实际上会继续存在,直到您单独移除其所有清理组件。
- 当实体被复制到另一个世界、在序列化过程中被复制,或被
EntityManager的Instantiate方法复制时,原始实体的任何清理组件都不会添加到新实体中。
清理组件的主要用途是在实体创建后帮助初始化实体,或在实体销毁后清理实体。例如,假设我们有一些代表怪物的实体,它们都有一个 Monster 标签组件:
我们可以通过查询所有具有 Monster 组件但没有 MonsterCleanup 组件的实体,来找到所有需要初始化的怪物实体。对于匹配此查询的所有实体,我们执行任何必需的初始化并添加 MonsterCleanup 组件。
我们可以通过查询所有拥有 MonsterCleanup 组件但不具备 Monster 组件的实体,来找到所有需要清理的怪物实体。对于匹配此查询的所有实体,我们执行所需的清理操作并移除 MonsterCleanup 组件。除非这些实体还保留其他清理组件,否则这将销毁这些实体。
注意 :在某些情况下,您可能需要在清理组件中存储清理所需的信息,但在多数情况下,一个空的清理标签组件就足够了。
清理组件分为四种类型:
- 实现
ICleanupComponentData的结构体:这是非托管IComponentData类型的清理变体。 - 实现
ICleanupComponentData接口的类:托管IComponentData类型的清理变体。 - 实现
ICleanupBufferElementData接口的结构体:动态缓冲区类型的清理变体。 - 实现
ICleanupSharedComponentData接口的结构体:共享组件类型的清理变体。
区块组件
区块组件是属于区块的单一值。
注意 :共享组件同样为每个区块存储一个值,但共享组件值在逻辑上属于实体而非区块(这就是为什么设置实体的共享组件值会将实体移至另一个区块,而非修改存储在区块中的值)。区块组件真正属于区块本身,且与非托管共享组件不同,非托管区块组件直接存储在区块中。
区块组件被定义为实现 IComponentData 的结构体或类,但区块组件的添加、移除、获取和设置需通过以下 EntityManager 方法实现:
AddChunkComponentData<T>:向区块添加类型为 T 的区块组件,其中 T 为托管或非托管的IComponentData。RemoveChunkComponentData<T>:从区块中移除类型为 T 的区块组件,其中 T 为托管或非托管的IComponentData类型。HasChunkComponent<T>:若区块包含类型为 T 的区块组件则返回 true。GetChunkComponentData<T>:获取区块中类型为 T 的区块组件值。SetChunkComponentData<T>:设置区块中类型为 T 的区块组件值。
Blob 资源
Blob(二进制大对象)资源是一种不可变、非托管的二进制数据块,存储于连续的字节段中:
Blob 资源具备高效的复制与加载性能,因其完全可重定位:所有内部指针均以相对偏移量而非绝对地址表示,因此复制整个 Blob 仅需逐字节复制即可。
虽然 Blob 资源独立于实体存储,但实体组件可以引用这些资源。
由于不可变性,Blob 资产天生支持多线程安全访问。
注意 :Blob“资产”这个名称容易产生误解:Blob 资产是内存中的一段数据,而非项目资产文件!但 Blob 资产能够高效便捷地序列化到磁盘文件中,从这个角度来说,称其为“资产”是恰当的。
创建 Blob 资产的步骤:
- 创建
BlobBuilder。 - 调用构建器的
ConstructRoot<T>方法来设置 Blob 的"根"(一个类型为 T 的结构体)。 - 调用构建器的
Allocate<T>、Construct<T>和SetPointer<T>方法来填充 Blob 的其余数据(包括BlobArrays、BlobStrings和BlobPtr)。 - 调用构建器的
CreateBlobAssetReference方法,该方法会复制构建器中的所有数据来创建实际的 Blob 资源,并返回一个BlobAssetReference。 - 释放
BlobBuilder。
当不再需要 Blob 资源时,应通过调用 BlobAssetReference 的 Dispose 方法进行释放。
烘焙实体场景中引用的 Blob 资源会随场景一起序列化和加载。这些 Blob 资源不应手动释放:它们将随场景自动释放。
重要提示 :包含内部指针的 blob 资源的所有部分必须始终通过引用访问。例如,BlobString 结构体中的偏移量值仅相对于该结构体在 Blob 内的存储位置正确;这些偏移量相对于结构体的副本是不正确的。
版本号
世界、其系统及其区块维护着多个"版本号"(这些数字会随特定操作递增)。通过比较版本号,您可以判断某些数据是否可能已发生更改。
所有版本号均为 32 位有符号整数,因此在递增过程中,这些数字最终可能在程序生命周期内发生回绕。比较版本号的正确方法依赖于 C#定义有符号整数溢出的特殊机制:
版本号包括:
World.Version:每当世界添加或移除系统或系统组时,该版本号就会增加。EntityManager.GlobalSystemVersion:世界内每个系统更新前递增的版本号。SystemState.LastSystemVersion:系统每次更新后立即被赋值为GlobalSystemVersion的版本号。EntityManager.EntityOrderVersion:世界中每次发生结构性变更时递增的版本号。- 每个组件类型都有独立的版本号,任何获取该组件类型写入权限的操作都会使其递增。可通过调用
EntityManager.GetComponentOrderVersion方法获取此数值。 - 每个共享组件值还带有一个版本号,每当发生影响包含该值的区块的结构性变更时,该版本号就会递增。
- 每个区块会为其中包含的每种组件类型存储一个版本号。当区块中的组件类型被写入访问时,无论是否实际修改了组件值,其版本号都会被赋值为
EntityManager.GlobalSystemVersion的当前值。这些区块版本号可通过调用ArchetypeChunk.GetChangeVersion方法获取。 - 区块还会存储另一个版本号,每当发生影响该区块的结构性变更时,该版本号就会被赋值为
EntityManager.GlobalSystemVersion的当前值。此版本号可通过调用ArchetypeChunk.GetOrderVersion方法获取。